Skip to content

Develop - #406

Merged
namedgraph merged 38 commits into
masterfrom
develop
Oct 16, 2026
Merged

namedgraph merged 38 commits into
masterfrom
develop

Conversation

@namedgraph

Copy link
Copy Markdown
Member

No description provided.

namedgraph and others added 3 commits October 5, 2026 00:15
The cli job built cli/ alone, so Maven resolved linkeddatahub-rdf at the release version from
Central. release.sh deploys the library only after pushing the tag, and Central publishes it
minutes later, while the job starts seconds after the push: it failed for 6.0.1 and 6.1.0, and
since it is the job that creates the GitHub release, neither got one. 85fde19 taught the
three test workflows to install rdf/ from the checkout first and missed this one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
namedgraph and others added 26 commits October 5, 2026 23:04
…t block of its own (#407)

* An assistant drawer: a question becomes a Web-Algebra plan, shown before it runs

The drawer on the right edge of every page for a signed-in reader sends a question
in natural language to the web-algebra sidecar's plan service, with the document the
reader is on, its dataspace's SPARQL endpoint and the earlier turns. The plan comes
back as its summary, the operations it invokes and the XML itself, and runs only on
Execute (or at once with "Execute by default"); the steps report as the executor
does, the result set or the documents written are shown, and the page catches up.

Each card keeps its plan and, once run, what it returned, so a follow-up that refers
to "them" is planned on that result as a VALUES block rather than on a new query. A
Clear button empties the log and both stores. LDH's operation family is addressed as
`waldh:` elements. The stack gains the web-algebra service and nginx's reserved
/webalgebra path with the caller's certificate; OPENAI_MODEL picks the planner's
model. The chat section of ldh.css parks the core Drawer under the sticky header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Serve the agent guide at /AGENTS.md, and say in it that the default graph is empty

A client writing a query against a dataspace's endpoint has to know that every
document is a named graph and a pattern outside GRAPH matches nothing; the
assistant's SPARQLString reads an endpoint's AGENTS.md as service documentation and
was answering "no results" for want of it. The repository's guide is shipped in the
WAR from where it lives, mapped to the default servlet beside robots.txt and
sitemap.xml so it is public and never enters the dispatcher, served as
text/markdown, and gains the named-graph paragraph in its Querying section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The assistant is part of the dataspace's panel, and its rows name what they return

The drawer moves inside #tab-body, beside the panes and under the dataspace tab bar:
the header and the tab bar keep the full width, the drawer starts where the panel
does, and its head takes the action bar's height so the two seams are one line. An
open drawer insets the panes and the footer rather than the frame. The create dock's
full-bleed margin and padding, computed on the viewport, slid its buttons under the
drawer; they now share --ldh-pane-w, the panes' width, which the open drawer narrows.

A result set's rows get their labels: the distinct URIs are looked up with the two
metadata queries a view block runs, against the endpoint the plan queried, and the
rows are drawn again with the metadata tunnelled to the link leaf when the labels
land. The UI spec measures the panes and the seam, and expects the written container
by its title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The model key is an optional secret, declared in the override like the OAuth ones

The base compose file no longer binds secrets/openai_api_key.txt, so a stack without a
key - the test workflows, a deployment that only runs plans - comes up; a deployment
that writes plans declares the openai_api_key secret and OPENAI_API_KEY_FILE in its
override, which is where the dev stack now has them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The assistant hands the planner the dataspace's ontology, and the agent guide says a join crosses documents

The dataspace's ontology, which every document advertises with Link rel=lds:ontology, is the
vocabulary its domain data is described in: labels, domains and ranges the planner's induced
schema lacks, and none of the documents' plumbing the induced one picks up. The client now keeps
the Link target the way it keeps the endpoint, lds:ontology() reads it, and the question goes to
the plan service with it, so a SPARQLString against this dataspace is written from GET of the
ontology rather than from ExtractOntology (REST-VKG #26).

The guide the query writer reads from /AGENTS.md gains the two rules the Northwind evaluation
showed the model breaking: a resource is described in the document about it, so a pattern that
follows a link to another resource needs a GRAPH of its own for each; and a property is used on
instances of its domain, so what the ontology declares on Person is reached through the
corporation's employee, not written on the corporation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* An ASK is answered with a boolean, which the assistant's query check depends on

The SPARQL endpoint answered an ASK with an empty result set, so the Web-Algebra SPARQLString
check - an ASK with the query's own pattern, to send a query that matches nothing back to the
model - never bit against this platform. The boolean result is Core 5.0.6: SPARQLResult, its
provider, and the ASK path of SPARQLEndpointImpl. Core is pinned at 5.0.6-SNAPSHOT ahead of
Web-Client's transitive 5.0.5, and SPARQLResultProvider is registered beside ResultSetProvider
on the server and on both clients. The pin resolves from a local install only until Core is
released, so an image build needs that release, or the snapshot deployed, first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* A plan the service refused reports as a failure in the drawer, with the service's reason

An envelope holding a message instead of a plan was shown as a reply whatever the status, which
read a validator's refusal - a parser's line and column - as the assistant declining in words. A
message on a 200 is still a reply; on any other status it is the service's reason for failing, and
the card now reports it as the failure it is, through the chain's own failure path, with that
reason as the detail rather than the status line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* A plan that stopped folds out the XML of the steps it did enter

A step row borrowed its operation's XML from the plan only when the executor's steps and the
plan's operations matched one to one, so a plan that failed at its tenth operation of nineteen
rendered every row without a fold-out, and so did one still running - only a plan that ran to the
end lined up. The steps are now paired with the plan's leading operations, as many as were
entered, in the order the executor enters them; a ForEach's iterations are more steps than
operations and still get none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The assistant is a block in the content body, with its composer docked onto the create bar

The assistant produces blocks, and the drawer kept it where blocks are not: a plan wrote into the
document, then the whole page reloaded to catch up, in a panel beside the content it changed. The
placement rule already said where it belongs - a form for what is added to the current document
renders inline - so it moves there. The conversation is an ephemeral block at the end of the
content body, before the create bar, so what a step writes lands above it in order; the composer
is a panel docked onto the bar's upper edge, opened by the bar's own button beside Create and
closed by it or by Escape. A page with no bar - a proxied resource, a search result - gets a bar
holding the composer alone, so reads work everywhere.

None of it is server-rendered but the button: a conversation exists only once the client runs,
and the body it sits in is replaced on every navigation and every write's catch-up. So the
conversation is not the DOM: each document's log is an element kept under the document's URI, the
one composer form is kept for the session, and ldh:ChatMount moves them into whatever body was
just rendered. A card is painted into its log whether the log is in the page or not, so a plan
still running when the body goes finishes into its log and is there when the document is next
shown; a card that reported a write comes through the catch-up with its steps, its documents and
its fold-outs. Conversations are per document, and the history the planner hears is that
document's.

The drawer, its edge sensor and its CSS go; the mount is guarded on the WebID itself rather than
the agent's document, which document() only has once something loaded it. The assistant spec
covers the new surface: opened from the bar and docked above it, a card in the body, Clear from
the block's head, and the card surviving the catch-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The composer is as wide as the content column, and starts where it starts

Docked onto the create bar, the composer took the bar's full width: the bar is full-bleed on the
left and padded on the right to the column's edge, so the composer ran from the viewport's edge to
the Create button while the content it serves sits in a centred column. Its left margin now makes
up the column's inset over the bar's own padding, and its width is the column's inside its padding;
margin plus width fill the bar's line exactly, which is what wraps the buttons below it. The spec
measures it against the column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The assistant is a component of the app kit: ldh-chat- classes, the kit's block header, its skin in app.css

The assistant had no place in the design system - no component, no card, no rule - so its markup
could only be read off the XSL. It is one now (ui_kits/app/Assistant.jsx, preview/component-assistant.html),
and the product uses the kit's vocabulary: the chat-* classes become ldh-chat-*, the block's head is
THE block header at compact density with Clear in its actions cluster, and the rules that are the
component's look - the create bar's, the block's, the turns', the plan card's, the step rows', the
composer's - move from ldh.css into the vendored app.css, pushed upstream in the same change.
What stays in ldh.css is the page's geometry: where the bar parks and how wide the docked
composer is, which only this page's column and pane widths can say.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* The card answers in words, with the trace folded under it

A plan's card showed its rows and its step tree, which is the machinery; the answer the reader
asked for was theirs to read off it. Once a plan ends, and once its rows have their labels, the
card asks the answer service beside the plan service for a reading - the question, the plan's
summary, the steps, the documents written and the labelled rows go, a sentence or two comes back
- and that sentence becomes the card's first line. The steps now live under a trace disclosure
whose summary is their count and the plan's time: open while the plan is being read and run,
since the rows are what there is to see, folded once there is an answer to read instead. The rows
stay on the card and stay the authority; a service that cannot answer leaves the card as it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* A result is a block in the card, drawn by the plan's hint, and Keep makes it the document's

A plan's result rendered as a bare table whatever it was. It now sits in a well of its own under
the card, headed like a block with the mode's label, and drawn through the document's own block
rendering by the plan's presentation hint: a result set as the results table or, when the plan
said chart, as a chart drawn into the card with the hint's axes; a graph as the view block's list,
grid or table. What the reader sees in the card is what a block in the document would show.

Keep, in the well's actions, writes it there: a plan of the card's own making - the query stored
on the document, the endpoint registered as a service first when it is not the dataspace's own, a
view in the mode drawn or a chart with the axes drawn, and an object block placing it after the
document's last block - run at once as its own card. A document the assistant wrote to comes back
in ContentMode, which is the mode that shows its blocks, so the kept block appears above the
conversation. The fragments carry their '#', since the waldh: operations resolve <fragment>
against <url> as a relative URI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* A write's report is not a result, and the folded trace shows it opens and how it went

Every write answers a one-row ?status ?url result set, and the card drew each one as a result
well with Keep, sent them to the answer pass as the rows - which then said no value was returned -
and kept them as the next question's "them". They are the documents written, which the card
already lists, so they are left out of all three, and the list counts each document once.

The trace folded under a summary that read as a caption: no chevron, no hover, and the green rows
seemed to vanish. The summary now has a chevron that turns when open and the outcome's glyph, a
green check or a red error, so the folded trace still says how the steps went.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep goes: a result reaches the document by asking for it

Keep pinned a result into the document as a view or chart built from the card's query. It
chose the block by the card's rendering, and turned "what's the current year" into a view over
SELECT (YEAR(NOW()) AS ?currentYear), which a view - a list of resources - cannot show, and whose
block then failed to render at all. With chats about to be stored in the document, every
result persists anyway, and putting one into the document's flow is a request in words that
the planner writes as blocks. The button, its write plan and its translations are removed; the
result wells stay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* A view lists what an aliased first variable binds, and says when it binds no resources

SPARQL.js gives an aliased projection, (?s AS ?resource), as a map rather than the string "?s",
and reading only the string left the view's focus variable empty and its whole render failing.
ldh:first-var-name reads both shapes.

An alias is bound only in the projection, so the rewrites that project something else or append
patterns to the WHERE (the result count, the container lookup, the parallax step) first wrap the
query as a subquery (ldh:wrap-subquery). Without it the container lookup joined on an unbound
variable, matched every triple in the store and exhausted Fuseki's memory. The count's projection
and the parallax step's WHERE now match the outer query only, not a subquery's.

A first variable that binds values, (YEAR(NOW()) AS ?year), gives rows the DESCRIBE finds nothing
for. The count tells the two apart, and the block says the query lists no resources rather than
that nothing matched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* A conversation is a block: each chat has its own composer, is stored turn by turn, and shows its plans resolved

The assistant moves from one ephemeral, client-kept log docked on the create bar to an ldh:Chat
resource placed by an ldh:Object block, like a view or a chart. A page holds as many chats as it has
chat blocks, each ending in its own composer; the bar's Assistant button starts a new one before the
bar, which is written - chat, object block and the document's next rdf:_N, in one conditional POST -
on its first question, so a click that asks nothing leaves nothing behind.

Every turn is an ldh:ChatTurn, the chat's next rdf:_N, written to the chat's own document once its
answer lands: the question, the answer, the plan and what the execution reported (result capped at
100), as XMLLiterals, and the outcome. The write catch-up waits for it, and the chat block's RowHook
draws every turn from the store, so a chat survives leaving the page, can be embedded, and continues
with its stored history. A reader without acl:Append sees the transcript and no composer.

The literals travel escaped, typed rdf:XMLLiteral: Jena's RDF/XML parser refuses an rdf:RDF inside
rdf:parseType="Literal", and a turn whose plan read a graph answered 400.

The trace shows the plan as it ran: a step that reported a value at run time (the query a
SPARQLString wrote, as wa:value) folds it out, and the operation around it shows the call replaced by
that value.

ldh.ttl and the rdf/ vocabulary gain ldh:Chat, ldh:ChatTurn and the turn's properties; the global
mount, the session composer and Clear are gone. tests/ui/specs/shell/assistant.spec.mjs is rewritten
around chat blocks (8 specs, 5 without a model).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* The assistant reads how to show a result as the client's mode URIs, and draws a grid and a timeline

A plan's <wa:present> now names the layout mode as ac:mode - ac:TableMode, ac:ListMode, ac:GridMode,
ac:ChartMode, the values a view block's ac:mode takes - and a chart's details as the properties a
chart block has: ldh:chartType, ldh:categoryVarName, ldh:seriesVarName. ldh:present-mode and
ldh:present-chart-type read them, and still read the table/list/grid/chart tokens and the
type/category/series attributes of turns stored before, so those draw as they did. A result well
carries its mode's URI in data-mode.

Specs: a stubbed grid plan whose graph depicts its resources draws one card per resource with its
image, live and again from the store; a stubbed timeline plan draws one labelled span per row, the
one whose end came from COALESCE included. Both run without a model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* An operation's XML fills its code field

The trace folds out each operation's XML in the kit's code field, which is a flex row built for the
kit's own text area, the one child that grows. The trace puts a plain pre there, which nothing told
to grow, so a plan of short lines showed its XML in a box as wide as its longest line inside a
field as wide as the card. The pre now takes flex: 1 and min-width: 0, as the text area does; long
lines still scroll inside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* The assistant spec passes the admin base positionally, as ldh takes it since #404

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Core 5.0.6 arrives through Web-Client 6.0.4, so the snapshot pin and the overlay exclude go

The boolean result of an ASK, which the assistant checks its queries with, shipped in Core 5.0.6 and
Web-Client 6.0.4 depends on it. The direct core:5.0.6-SNAPSHOT dependency, which only resolved from a
local repository, and the overlay's exclude of its own Core jar are no longer needed; the
SPARQLResultProvider registrations compile against the released Core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* A bar chart's value axis starts at zero, a chart's Save hides with its controls, and a maximised graph fills the viewport

- chart.xsl: a bar's length is its value, so the bar chart's value axis takes minValue 0 (ignored when
  the data goes below it)
- app.css: the chart's Save stores what its controls picked, so its footer is hidden while the
  controls are collapsed
- ldh.css: a maximised 3D graph canvas is pinned with inset: 0 and auto size, rather than 100% of a
  containing block that is not the viewport

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…wherever it is rendered

A chart, a map and a 3D graph render into a canvas a library draws on, and each place that rendered
one started it by hand: a document in its mode (client.xsl), an object block in its mode (object.xsl,
whose copy said "TO-DO: reuse similar initialization code from client.xsl"), a view switched to it
(view.xsl), a chart block's three control handlers and its results response (chart.xsl), the query
block's chart (query.xsl) and the assistant's (chat.xsl).

Now the canvas element is the thing dispatched on: ldh:InitCanvas, one template per canvas class.
- chart-canvas (chart.xsl): the data table from a graph or a result set, by templates on rdf:RDF and
  srx:sparql (ldh:ChartDataTable), the chart type, category and series the caller gives or a table of
  every property or variable (ldh:ChartSeries), the data table kept on the caller's cache.
- map-canvas (map.xsl): the OpenLayers map kept on the cache, created the first time and moved onto
  the canvas after; features from the view's query when there is one, else from the graph.
- graph-3d-canvas (graph3d.xsl): the graph started the first time and fed new results after, so a
  view coming back to it redraws the same graph; graph-results lets an object block show its whole
  document. A canvas emitted just now falls back to 600px, having no height yet.

Callers apply the mode to the canvases they rendered, with the results, the cache and what else the
canvas needs as tunnel parameters. The chart block's three onchange handlers are one; the view's
three mode branches are one call.

Specs: view/modes.spec switches a view to chart, map and graph (and away from the graph and back);
object.spec draws an object block in each canvas mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…el when there is none

The coverage inventory names the create bar's Assistant button instead of the retired chat
drawer. The assistant's model-backed tests skip when the plan service answers 503, its reply
when no model key is configured, as on CI; any other failure still fails them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…es whole milliseconds

The http-tests override moves the secretary keystore and the server certificate under tests/http/ssl
for nginx and linkeddatahub but left web-algebra on the repository root's ssl/, empty on a CI
checkout: the servlet failed to start and every /webalgebra request answered 500, so the
assistant's model tests could not see the 503 they skip on.

The data table converters computed a datetime's milliseconds in floating point, giving
984.9999999999999 for .985 on SaxonJS. Google Charts parses a Date(...) string only when its
numbers are whole, and threw on the string it kept instead. Rounded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e block around it

A property list nested in a value cell queried the enclosing block, so the narrow-container
collapse never fired while every level spent 264px of fixed track. Schema.org JSON-LD three
blank nodes deep (a Rotten Tomatoes filmography) left the innermost value 84px wide, one word
per line. The nested list is now its own query container and fills the cell's flex row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…result set

An upstream's SPARQL results content type does not say whether the body holds rows or the boolean
an ASK answers with, and reading a boolean as a ResultSetRewindable threw. The results branch now
reads a SPARQLResult and serves a boolean in the result set formats that have a boolean writer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The proxy replaced its own Vary with the upstream's, so an upstream sending Cache-Control without
Vary: Accept left the proxied RDF cacheable under the bare ?uri= URL: varnish-frontend served it to
the next anonymous browser navigation, which downloaded RDF/XML instead of getting the shell. Rotten
Tomatoes (max-age=300, Vary: Accept-Encoding) and schema.org (max-age=600, no Vary) both did it.
The local and upstream values are now merged, always including Accept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nto a 500

Every exception mapper negotiates its response through Request.selectVariant(), which parses Accept,
Accept-Language, Accept-Charset and Accept-Encoding, and a parse failure there replaced the mapper's
status with the container's 500. On linkeddatahub.com that was crawlers replaying
?accept=application%2Fld+json, whose + decodes to a space: ApplicationFilter copied
"application/ld json" into Accept, and the 403 for the protected page came back as a 500, about
65 a day. AcceptHeadersFilter replaces AcceptLanguageFilter and drops any of the four headers that
does not parse. ApplicationFilter refuses an accept parameter that is not a single media type with
400 before touching the header; that also covers ?accept=foo, which Jersey reads as foo/* but
getWritableMediaTypes() failed on with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…takes the dataspace down

ontapi files every graph of an imports closure under the ontology IRI its header declares, and when that
differs from the URI it was imported by, it writes an owl:imports of the declared name into the importing
graph. That graph was the raw copy in the shared ontology repository, so the write outlived the closure:
the next rebuild over the same cache resolved both names, the declared one into a placeholder for a URI
that did not dereference to the document, and ontapi refused the second graph of that name. Every request
to the dataspace failed with "Another graph with name ... is already in the hierarchy" until the cache was
cleared. The upload test left such an import behind (an upload declaring https://example.org/test#).

ScopedGraphRepository now hands ontapi the shared graphs wrapped in a Delta, one per graph instance, so
its writes stay with the closure; the root ontology is read through the view as well. A graph resolved
by one URI that declares another is logged.

Saving an ontology whose owl:imports names a document declaring another ontology is rejected with 422,
naming the declared IRI. The check validates the whole representation, as SPIN and SHACL constraints do;
an unresolvable import, or one without an ontology header, is let through.

The upload fixture declares its ontology relative to where it is uploaded (<#>) and the test imports the
upload's hash URI; a new test covers the rejection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… declare

An owl:imports names an ontology, and SP, SPIN and OWL name themselves without the hash their terms carry:
<http://spinrdf.org/sp>, <http://spinrdf.org/spin>, <http://www.w3.org/2002/07/owl>. The sp:, spin: and owl:
prefixes expand to the term namespaces, so ldh.ttl, dh.ttl, def.ttl and lds.ttl imported names no document
declares, which the closure warned about on every build and an ontology saved the same way is now refused
for. lacl.ttl, adm.ttl and spin.ttl already imported the declared IRIs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ldh:send-request exists only on the server

ldh:query-result gets an implementation per engine, chosen by use-when: on the server it POSTs the query
as application/sparql-query through the ldh:send-request extension function, on the client it keeps the
document() GET. Every direct ldh:send-request call in document.xsl and layout.xsl becomes an
ldh:query-result call, and the stub declaration is gone: shared code no longer compiles a call that
silently returned nothing in the browser, and one that slips in fails the SEF compile instead.

ac:value-intersect and ac:value-except are removed. Every caller compared the keys of two triple maps,
which map:contains() answers directly, without distinct-values() or a nested general comparison.

Verified on the local stack with the UI specs memento, document-form, row-form, prop-list, object and
lang-field (18 passed). constructor-editor was not verified: the stack runs the dev config, which lacks
the packages.localhost dataspace the suite publishes its fixture package to, and with the public
package substituted the specs fail on the card's data-graph, before any save.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…howing a 502

The proxy answers 406 for such a page - it has no RDF representation, which is what the
caller asked for - where it used to answer 502, as if the origin had failed. The client
opens a 406 for an external document the way a target="_blank" link would, with no
opener, and navigates there itself when the popup is blocked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
All globals share one namespace across the import tree, where Web-Client and
package stylesheets declare their own unprefixed ones, so an unprefixed LDH
param could be overridden by accident. Overriding $ldh:select-labelled-string
and its peers is now deliberate, and the prefix marks who owns the hook.

The button/helper-text class params in client/form.xsl keep their names: they
override rdfa-editor/classes.xsl by name. Template-local params of the same
name (page-size in navigation.xsl, location-mapping in layout.xsl) are
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…wn as a map or a 3D graph

The assistant's result wells had a mode switch of their own (ldh:ExecutionResult) that knew a table,
a list, a grid and a chart, and drew only a chart canvas, by id. A well now renders its result through
ldh:ViewModeChoice, the switch every view uses, and its canvases are drawn as every canvas is: the
class-keyed ldh:InitCanvas sweep over the well. A result set is a table or a chart; a graph is a list,
a grid, a table, a map or a 3D graph, by the plan's hint (ldh:ResultMode). Each well is named after its
result's place in what the plan returned, so its canvases are named apart, any number to a page and
pane. Labels arriving for a result set redraw its well the same way, a chart's included.

- ldh:ViewModeChoice takes no $select-xml for results with no query of their own, and its table mode
  draws a SPARQL result set (srx:sparql in ldh:TableViewBlock).
- $ldh:canvas-classes (resource.xsl) maps a layout mode to its canvas class; the pane, object-block and
  assistant sweeps read it instead of each listing the classes.
- The well's icon is the shared $ldh:mode-icons entry; the chat's own CSS for its table and chart
  canvas gave way to the well's (synced to the design system's ui_kits/app/app.css and Assistant.jsx).
- Shared modules no longer name the assistant in their comments.

Verified on the localhost:4443 dev stack (make sef, make up -- --build, web-algebra rebuilt from
REST-VKG feat/generic-plan-checks): tests/ui assistant.spec.mjs - the model-free specs, including a new
one that asks for a map and then a 3D graph in one chat, draws both canvases on one page with distinct
ids, and draws them again from the stored turns after a reload - and the map, graph3d, object, chart,
query and view block specs all pass. Not run: the concept-hierarchy, view create and view empty specs,
which need the taxonomy package this stack has no packages dataspace to publish, and the specs that ask
the model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e obsolete string-language Model.read

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d to ignore shutdown

A target with a filter registered on it, such as the WebID delegation filter, runs on a connector of its
own over the client's pool, and Jersey closes that connector once it is garbage-collected - which, unless
the pool is shared, shuts the pool down under every other request (jersey#4449). The three pools in
getClient, getNoCertClient and getVerifiedClient guarded against it by overriding shutdown() to do
nothing and close() to shut down. They now set ApacheClientProperties.CONNECTION_MANAGER_SHARED, the
setting Jersey provides for a pool the application owns, and drop those overrides. Nothing closed these
pools deliberately, so no shutdown path changes. The releaseConnection override stays: the reuse of a
connection that presented a client certificate (jersey#2855) has no setting.

ConnectionPoolTest runs 20 requests per client, each through a target with its own filter and with the
collector run between them, against local servers: getClient over HTTPS asking for a client certificate
(the certificate is presented, its one connection reused), getNoCertClient over the same server (none
presented, one connection), and getVerifiedClient over plain HTTP, since it trusts the JVM's CAs only.
The same test passes against the previous subclass, and with neither every request after the first
collection fails with "Connection pool shut down". The keystores under client/ssl are self-signed, for
localhost and a test agent, valid for 100 years.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the top of the page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e assistant landed

Web-Client 6.0.5 brings the proxy's ASK boolean (AtomGraph/Web-Client#98) and drops the unused
JSONLDWriterAdapter (#99). Built with make sef against it: client-6.0.5-classes.jar is the one
Web-Client jar in the WAR and the client stylesheet compiles.

The Unreleased section gains the stylesheet migrations (the ldh: prefix on top-level params and
variables, ldh:query-result), the assistant's map and 3D graph, the proxy, Accept and ontology
import fixes, and the Web-Client bump.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iption, advertising queries only

Core 5.0.7 answers a query-less GET on the SPARQL endpoint with its SPARQL 1.1
Service Description instead of a 400. LDH narrows the supported languages to
sd:SPARQL11Query, since writes go through the Graph Store Protocol.

The Core endpoint constructors now take UriInfo; Namespace passes its own and
drops the duplicate field. <ns> keeps its query-less behaviour (the namespace
ontology on end-user, 400 on admin).

Web-Client 6.0.6-SNAPSHOT, which carries Core 5.0.7-SNAPSHOT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mit baseline is not cut short

Two intermittent UI failures, both the specs' own timing:

- code-block: the prose block sits below the fixture page's view, which expands when its
  results arrive. A hover before then landed on the code block, which the view pushed out from
  under the pointer onto its rows (reproduced locally with the view held back 1.5 s: y 263 -> 818,
  opacity back to 0). lib/settle.mjs gains loaded(), which waits until no block carries
  aria-busy; the spec opens the page through it before any hover or click.

- rate-limit: the page's two views each send the same /ns parallax label lookup, so every load
  sends it twice. The baseline stopped recording at networkidle, which the second lookup can
  follow - measured 38 ms after it on a 4x throttled CPU - so it counted one, and the refused load,
  which waits much longer, "over-fetched" the second. quiet() ends both loads the same way: after
  networkidle, until no block request has gone out for 2 s.

Verified locally: code-block 15/15, rate-limit 8/8 including 4 runs at 4x CPU throttle. The
rate-limit race could only be measured here, not forced to fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d of showing the URI's fragment

ldh:form-horizontal-submit-success redrew the row from the submitted triples alone, which carry a resource value as its URI, so a category saved on a product read "this" until the page was reloaded. The redraw now runs the object-metadata chain over those triples first, as every other block render does, and passes the labels to ldh:BlockRow; if the label query fails, the row is drawn without them as before.

row-form.spec.mjs covers the inline form's save paths: a changed resource value is labelled after the save, a constraint violation keeps the form open with the field marked and the document unchanged and then saves once corrected, and a save over a document changed since the form opened is refused 412 with the failure shown in the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…restarts (#412)

* SPARQL endpoints are capped by concurrency, and a Fuseki out of heap restarts instead of running on broken.

nginx keys three zones on an external client's /sparql request only: at most NGINX_SPARQL_CONN_PER_CLIENT (2)
in flight per client, NGINX_SPARQL_CONN_TOTAL (8) across all of them, and NGINX_SPARQL_RATE (2r/s, burst 10),
each answered with 429. Every other path and every internal caller maps to an empty key, which nginx does not
count, so the platform's own queries and the generic locations are untouched. Rate limiting alone let a few heavy
queries arriving slowly exhaust the store's heap together; the query timeout bounds their time, not their memory.
Fuseki gets -XX:+ExitOnOutOfMemoryError in JAVA_TOOL_OPTIONS, which deployment overrides of the heap leave intact,
so an exhausted heap ends the JVM and restart: on-failure brings it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Adversarial SPARQL load tests: the concurrency cap and OOM restart, proven under load

Three scenarios drive query load through the front door and watch what it does to the
platform, each asserting the behaviour PR #412 adds, so they pass with its guards and fail
without them:

- sparql-heavy-burst-site-stays-up.sh: more long queries at once than there are request
  threads. Without the concurrency cap every thread ends up on a query and the site stops
  answering; with it at most NGINX_SPARQL_CONN_TOTAL are in flight, the overflow is 429, and a
  visitor fetching the root is still served. (Measured: exactly 8 reach the 60 s timeout, the
  rest 429, the probe never drops.)
- sparql-oom-fuseki-recovers.sh: a query that materialises a large intermediate result reaches
  OutOfMemoryError against the stack's small heap before the query timeout. ExitOnOutOfMemoryError
  + restart ends the JVM and brings it back, so the store answers a trivial query again within
  the recovery window instead of running on broken.
- sparql-flood-rate-limited.sh: many trivial queries as fast as possible. The request-rate limit
  sheds most as 429 and the site keeps answering.

The query load comes from `adversary` containers on a 100.64.0.0/24 network (carrier-grade NAT,
which the front door's RFC 1918 exemption does not skip), so each is a distinct external client to
the caps; the site probe runs from the host (exempt, like a real internal check). The dataspace is
made public first - anonymous /sparql is the surface under test. Fuseki's heap is dropped to 512m so
the OOM path is reachable; the queries themselves need no seeded data (self-joins of the empty
store's own quads).

tests/load/compare.sh measures the scenarios across two or more revisions in MEASURE_ONLY mode for a
before/after. The CI workflow builds the ldh CLI, makes the dataspace public, and points the suite at
the running stack via COMPOSE_CMD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Load-tests: set up JDK 21 before building the ldh CLI

The make-public step needs the ldh CLI, built with `mvn` in the job; ubuntu-latest's default JDK
is older than the project's release 21, so the rdf build failed with "release version 21 not
supported". Set up Temurin 21 first, as the HTTP-tests workflow does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Load-tests: drive the SPARQL burst from one loop per worker, not one exec per request

attack() spawned a `docker compose exec` for every request. On a 2-core CI runner the burst buried
the box under compose-CLI launches and starved the very stack under test, so the suite ran for tens
of minutes instead of its nominal duration. Each worker now runs a single back-to-back curl loop
inside its adversary container for the whole duration - one exec per worker, the request rate paid
in-container. The scenarios are unchanged (heavy burst still sees exactly the 8-query cap reach the
timeout, the OOM store still restarts and recovers, the flood is still shed as 429) and now finish in
about their nominal wall time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Load-tests: neutral naming, tunable thresholds, firmer assertions, robust output

Follow-up polish on the SPARQL load tests:

- Rename throughout to plain load-testing terms (loadgen service/network, run_load, cpu-bound/
  memory-bound queries, sparql-concurrency / sparql-rate-limited / sparql-heap-fuseki-recovers) and
  neutral wording in comments and messages. No behaviour change.
- run.sh readiness wait now accepts 200 as well as 403 (the dataspace is public by the time the
  suite runs) and is bounded at 300 s, so a stack that never comes up fails instead of hanging.
- Thresholds are env-overridable with documented defaults: CONC, DURATION, PROBE_BOUND (the
  concurrency test's answer deadline) and MIN_REJECTED_PCT (the rate-limit test's shed share).
- Firmer assertions: the concurrency test checks the slowest probe against PROBE_BOUND and names the
  configured cap; the rate-limit test requires a minimum rejected percentage rather than merely
  more-429-than-200; the heap test now asserts the container actually restarted (the guard fired),
  not only that it ends up healthy.
- run_load writes one file per worker and concatenates, so result lines can never interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Load-tests: queries that hold their slot, assertions that pin each guard down

The cpu-bound query counted the stock 24-quad store's five-way join in ~4 s, so it never reached the 60 s
timeout and the concurrency test passed without pressing on the cap. Six-way runs past it with a flat heap.

The concurrency test now rebuilds from each response's start and end how many queries were in flight at once:
the total must reach NGINX_SPARQL_CONN_TOTAL and never pass it, no client may pass its per-client cap, and a
rejected worker backs off instead of spinning into the rate limit. The rate test sends one worker per client,
so the concurrency caps cannot answer, and bounds each client's accepted requests by burst + rate x window.
The heap test requires the JVM's "Terminating due to java.lang.OutOfMemoryError", so a container killed at
its memory limit no longer passes as the guard.

curl writes its -w line on failure too, so the "|| echo 000 0" fallbacks recorded every failure twice. run.sh
waits for the platform before each test, not only the first, and no longer runs compare.sh as a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Load-tests: drop compare.sh, the before/after measurement for this one change

Once the guards are on develop there is no "before" left to measure against, and the three tests assert
the guards on every CI run. The MEASURE_ONLY exits served only compare.sh, and run.sh no longer has to skip it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… short

The first block a write added was picked with [1] on the last step of the path, so it applied per
member and returned every new block. An assistant write always leaves two - the chat's own block,
written by its first question after the page was loaded, and the one the plan added - and the
zero-or-one variable refused them: the reload stopped once the body was drawn, with no breadcrumb,
no scroll and the chat card never finished. The path is parenthesized, and a block placing a chat
is not taken for what the plan wrote.

A spec answers the plan service and the executor itself, the executor's write made with the CLI,
and asserts the reloaded page keeps its breadcrumb, shows the added block in view and finishes the
card.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
9fbe050 redraws a saved row once its values' labels are in, rather than at once. ldh:form-horizontal-submit-success then calls rdfae:hide-overlay, which returns focus to the editor's active block, and that editor was still in the page: the redraw used to have removed it already. When the labels arrived, the redraw removed an editor holding focus. Chrome fires focusout for a focused element being removed (Firefox does not), the XHTML autosave took it for the author leaving an unchanged block and cancelled it, the redraw replaced a node the cancel had moved, the fallback render failed on the detached block, and the new block vanished from the page although it was saved.

The save is the editor's exit, so its active block is cleared before the overlay is hidden and focus does not go back into it. The labelled, delayed redraw of 9fbe050 stays.

xhtml.spec.mjs adds a block in Content mode, types a sentence and clicks outside, then asserts the sentence is on the page in read mode, still there two seconds later, and stored. It failed on develop with the removeChild page error; with the fix it passes, as do row-form, annotation-dialog and the other block specs (88 passed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
namedgraph and others added 9 commits October 16, 2026 18:47
…op stylesheets receiving credentials (#415)

* /context publishes the dataspaces and their admin pairing, and stylesheets no longer receive the context dataset

$lds:Context passed the whole context dataset to every server-side transformation: the dataspace
descriptions, but also the SPARQL services behind them, their backend endpoints and the credentials
for them. Every module of the composed stylesheet could read it, package stylesheets included.

PublicContext copies a whitelist of the lds:Dataspace descriptions only (type, title, description,
origin, ontology, stylesheet, lds:public) and adds lds:admin to each end-user dataspace: the base URI
of its administration application, derived by the server from the admin subdomain as before. It is
served at /context on every dataspace, public through seeded authorizations on both origins, and the
stylesheet param is gone. End-user responses also link their admin application with
rel="lds:admin", so that no client derives it from the URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stylesheets read the dataspace types and the admin pairing from /context instead of the admin. subdomain

lds:context() has a server twin that dereferences /context and a client twin that reads the copy the
bootstrap preloads, and lds:dataspace-description() moves into the shared library. The "is this the
admin app" checks test lds:AdminDataspace, the admin URL comes from lds:admin, and the admin app finds
its end-user ontology through the dataspace that names it as its lds:admin, so ldh:parent-origin goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ldh finds the admin application from the end-user base, installs a namespace ontology from a file, and waits for imports

The admin commands take the end-user base and follow its lds:admin link, falling back to the base as
given, so a script no longer prefixes admin. to the host; URIs.adminBase goes. --proxy connects to the
proxy but keeps the logical Host, which is how nginx routes dataspaces, so no admin proxy is derived
either and URIRewriter goes.

admin import ontology --file installs a local file as the namespace ontology in one step: the
ontology IRI from the lds:ontology link, the namespace document replaced keeping its seeded header,
the cache cleared. It replaces the reset PATCH, the POST with an @base and the clear that every app
scripted. import csv|rdf print the import they create, and --wait waits until it has ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The changelog, CLAUDE.md and the CLI README describe /context, lds:admin and the new ldh options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The import commands' target is described as the document the import is added to

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The admin dataspace's /context is publicly readable

The admin ACL query honours public read authorizations only for an allow-list of URIs (the
namespace, sign up, WebID profiles), so the seeded public-context authorization had no effect on the
admin origin and its /context answered 403 - which also stops the client-side stylesheet, since the
bootstrap preloads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The bootstrap preloads /context as RDF/XML

Its fetch sends no Accept of its own, so */* negotiated the HTML representation, which is not XML:
the document pool's parse failed and the client-side stylesheet never started. The preload asks for
RDF/XML with the accept parameter, as the namespace documents beside it do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* /context negotiates over the model media types, so */* no longer selects the TimeMap writer

Returned without a variant, the model went to whichever Model writer Jersey found first for */*,
which was the link-format TimeMap writer, and that writer only handles a TimeMap: a 500. The variants
are the registered model media types, as for the documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* GET-context.sh asks with Accept */* too, as the browser's preload does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… the documents do (#416)

Both returned their model with Response.ok() - /context selecting its variant by hand since the last
commit, /settings not at all, which left it open to the same */* 500 through the TimeMap writer. Core's
Response negotiates over the registered model media types and adds the per-variant entity tag, Vary
and the precondition evaluation; every Dispatcher endpoint that returns a model now goes through it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…efined view no longer flashes its own metadata before its results

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…414)

* An ldh Claude Code plugin teaches agents to build dataspaces, datasets and apps with the CLI

The plugin (plugins/ldh/, published by the marketplace in .claude-plugin/) has four skills:
dataspace provisions a dataspace in config/, dataset writes the namespace ontology and imports
CSV/RDF (or builds a large dataset standalone with CSV2RDF, JSON2RDF or Saxon and bulk-loads it),
app declares documents, views and charts and installs them with ldh push, and package writes an
XSLT package against the open modes in hooks.xsl and publishes it to a registry.

plugin.json carries the platform version: release.sh and make cli-version set it along with the
rdf/ and cli/ poms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The ldh skills install the namespace ontology with --file, wait for imports with --wait, and check /context

ldh admin import ontology --file replaces the reset PATCH, the POST with an @base and the clear, and the
admin commands follow the end-user base's lds:admin link, so no skill derives an admin. URI; imports
print their URI and --wait replaces the polling loop; the dataspace skill verifies a new dataspace in
/context, and the 403 pitfall of clearing against the end-user base is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The dataset skill's bulk-load pitfalls: JENA_HOME must point at 5.6, and make public can race Varnish

Verified by running the standalone path end to end against a fresh stack: 5.6's arq still loads 6.x
jars when JENA_HOME names a 6.x install, arq has no --results=trig, and make load's make public can
reach a Varnish cache still restarting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The ldh plugin has a README with install steps, the skills and example requests

Claude picks a skill from its description, so a user never has to name one; the examples show what to
ask, two of them the prompts the skills were verified with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The ldh skills declare a viewed property's range, and check that ldh matches the instance

Found by a fresh session following the skills end to end: the author page's inverse view never
appeared, because a property view shows only on the types in the property's rdfs:range (or rdfs:domain
for ldh:view) and the dataset skill's example declared none; with the range the page lists the books.
The session also met the published image, older than the CLI, which advertises no lds:admin link, so
admin commands answered 403 for the end-user base; every skill now says to match the two and how to
tell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The next release adds /context, the lds:admin link, ldh admin import ontology --file and ldh import
--wait, and breaks $lds:Context, the import commands' output and --proxy's Host handling - a minor
release, as 6.1.0 was. The rdf/ and cli/ poms and the ldh plugin follow the platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he editor's pure-XSLT cm:c14n (#417)

ldh:canonical-content stored Saxon's plain serialize() output since the
xml-c14n-sync lib was dropped; it now runs cm:c14n on the wrapper div, which as
the c14n apex declares the XHTML namespace once. View source shows exactly that
stored literal. xsl/rdfa-editor/ is synced from RDFa-Editor (canonical-xhtml.xsl,
edit.xsl), and the stale xml-c14n-sync credit leaves the README.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…anded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the separated table border model

An embedded card draws its hairline in the carrier's inner curve, read from
--ldh-block-radius, which skins and the block-edges tweak now set. The
`border-radius: inherit` it used reached only the DOM parent, which has no
radius, so the hairline's square end stood proud of the carrier's corner in
every skin.

Tables use border-collapse: separate, since the collapsing model ignores
border-radius on the table: its border drew square while the head cells
rounded inside it. Row rules move onto the cells and the four corner cells
round to the table's inner curve.

Driven with Playwright on a ContentMode document: the embedded card's
corners match the carrier's inner curve in m3, glass, glasspro, aurora, no
skin, sharp and m3+flow; tables are separate with matching corner cells and
row rules; the sticky head still pins; no dialogs or page errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@namedgraph
namedgraph merged commit cb6d7cf into master Oct 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant